dockview 기반 widget docking 시스템
마지막 수정일 · 2026. 07. 12.
TL;DR
치과 EMR을 만들면서 사용자 테스트를 돌려보니 병원마다 화면 쓰는 방식이 전부 달랐다. 요청을 다 받아서 만들어주면 병원 수만큼 유지 비용이 늘고, 안 만들어주면 애초에 도입을 안 한다. 그래서 "사용자가 직접 화면을 구성하되, 어떻게 구성해도 깨지지 않게" 하는 게 요구사항이 됐다.
- 화면이 아니라 위젯을 만든다 — 데스크·차트·예약·청구 등 9개 화면을 dockview 위의 보드로 바꾸고, 화면을 이루던 조각을 위젯 45종으로 잘라냈다. 자유도는 열되
minSize·span· 축약 단계(forms) · 코드 프리셋 · 초기화 한 개로 묶어서 깨지지 않게 했다 - 위젯은 props로 상태를 안 받는다 — 패널 id(
type#instance) 하나만 알고, 자기가 소비할 문맥과 설정을 스스로 찾아온다. 상태는 수명·범위 기준으로 L0~L6 일곱 단으로 나눴고, 공유 문맥(환자·날짜·의사) 세 축을 패널 단위로 따라가거나(follow) 고정(pin)한다 - Laboratory — 도킹을 걷어내고
PanelIdContext만 주입해서 모든 위젯을 한 그리드에 실제 컴포넌트로 띄우는 dev 화면. 축을 하나씩 돌리면서 캐시 키·관찰자 수·오류 경계를 본다. 레지스트리가 기계가 읽는 선언이라 에이전트가 메타에서 배치를 스스로 만들어 e2e까지 돌릴 수 있다
문제상황
시제품을 들고 여러 병원을 돌았는데, 기능 피드백보다 화면 배치 요청이 먼저 왔다. 접수목록을 오른쪽에 두고 싶다, 예약 캘린더를 차트 옆에 붙여 달라, 수납은 두 번째 모니터로 빼 달라. 데스크 인원, 진료실 수, 원장이 직접 접수를 보는지가 병원마다 다르니 당연한 요청이었다.
문제는 이 요청들이 하나의 "좋은 기본 화면"으로 수렴하지 않는다는 거였다.
- 병원별로 만들어주면 → 병원 수만큼 늘어나는 유지 비용
- 안 만들어주면 → 기존 프로그램에서 손에 맞게 다듬어둔 배치를 버릴 이유가 없어서 도입 자체를 안 함
그래서 요구사항은 이렇게 정리됐다. 사용자가 스스로 화면을 구성할 수 있어야 한다. 그런데 어떻게 구성하더라도 화면이 깨지거나 데이터가 어긋나면 안 된다.
앞 문장만 보면 "자유로운 대시보드"가 답 같은데 뒷 문장이 그걸 막는다. 진료실 모니터는 작고, 사용자는 개발자가 아니고, 환자 정보가 잘못 뜨는 건 이 도메인에서는 사고다. 열어두되 너무 자유롭지 않게 만드는 설계가 필요했다.
결정 : 화면을 위젯으로 구상
데스크, 차트, 예약, 청구, 서명, 검진, 문서, 사진, 고객관리. 이 9개 화면을 고정 레이아웃으로 만드는 대신 각 화면의 조각을 위젯으로 잘라내고, 화면은 위젯을 담는 보드로 바꿨다.
보드는 dockview 위에서 돈다. VSCode식 분할·탭·드래그, 그룹을 OS 창으로 빼는 팝아웃, 배치 JSON 직렬화가 이미 있어서 엔진을 새로 만들 이유가 없었다. 대신 dockview 내부에 손대는 코드는 파일 하나(dock-snap.ts)로 못 박았다. 업그레이드로 내부가 바뀌면 그 기능만 조용히 꺼지고 앱은 안 깨진다.
설계의 전부는 자유도를 어디서 열고 어디서 닫을지였다.
사용자가 정하는 것
- 어떤 위젯을 놓을지 (45종, 용도별 13섹션 팔레트)
- 어디에, 몇 개, 어느 창에 놓을지
- 위젯의 보기 방식 (접수목록: 큐 · 표 · 칸반 · 레일)
- 보드마다 워크스페이스를 여러 개 만들고 전환
- 특정 패널을 특정 환자·날짜에 고정(pin)
시스템이 보장하는 것
- 위젯마다
minSize와 4×2 격자 기준span이 있어서 그보다 작게는 못 만든다 - 셀이 작아지면 위젯이
full → compact → mini로 스스로 축약된다 - 어떤 배치든 같은 환자·날짜를 본다. pin한 패널만 예외다
- 초기화 버튼 하나로 항상 "만든 순간"으로 돌아간다
- 보드 구조가 바뀌면 옛 저장 배치는 자동 폐기된다 (보드 id에 버전을 박음:
claim-v3)
왼쪽은 병원들이 요청하던 것이고, 오른쪽 덕분에 왼쪽을 열 수 있었다. 축약 단계는 "이 위젯을 아무리 작게 만들어도 이 모양까지는 보장한다"는 계약이고, 프리셋은 JSON 파일이 아니라 (api: DockviewApi) => void 코드 빌더라서 사용자가 아무리 어질러도 돌아갈 원점이 코드에 남는다.
되돌리기 모델도 일부러 단순하게 잡았다. 명시적 저장 없이 자동 저장하고, 워크스페이스 카드마다 초기화 버튼 하나만 뒀다. 도킹 레이아웃에서 사용자가 원하는 건 "조용히 기억되고, 망치면 한 번에 원점"이지 버전 관리가 아니기 때문이다.
고민 1 — 아무 데나 놓아도 상태가 맞아야 한다
위젯을 자유롭게 놓게 하는 순간 "화면이 상태를 갖고 자식에게 내려준다"는 모델이 무너진다. 같은 환자 정보 위젯이 한 보드에 두 개 있을 수 있고, 하나는 다른 모니터의 팝아웃 창에 있고, 그중 하나는 아까 보던 환자에 고정돼 있어야 한다. props로는 표현이 안 된다.
그래서 원칙을 하나 세웠다. 위젯은 props로 상태를 받지 않는다. 위젯은 자기 패널 id(type#instance) 하나만 알고, 그 id로 자기가 소비할 문맥과 자기만의 설정을 스스로 찾아온다. 도크 안이든 팝아웃 창이든 실험실에 홀로 띄워지든 코드 경로가 같아진다.
다음은 "누가 무엇을 얼마나 오래 기억하는가"였다. 상태를 수명과 소유 범위로 일곱 단으로 나눴다. 추상적으로 설명하는 것보다 위젯 하나를 놓고 보는 게 빠르다. 진료차트 위젯은 일곱 단을 전부 쓴다.
사용자 입장에서 다시 읽으면 다음과 같다. 무엇을 하면 어느 단이 바뀌는지가 곧 "어디까지 기억되는지"다.
| 사용자가 하는 일 | 단 | 기억되는 범위 | 다른 진료차트 위젯도 바뀌나 |
|---|---|---|---|
| 접수목록에서 환자 클릭 | L2 | 지금 세션 | 따라가는 패널은 전부, 고정된 패널은 X |
| 기록 저장·수정 | L1 | 서버 | 같은 환자를 보는 위젯 전부 (invalidate) |
| 글자 크기·최신순·날짜 구분선 변경 | L0 | 이 PC | 이 PC의 진료차트 전부, 다른 자리는 X |
| 패널 드래그·리사이즈·팝아웃 | L3 | 이 계정 (서버 동기화) | 해당 없음 (배치 자체) |
| 기록 행 선택, 작업일 변경 | L4 | 이 보드 · 세션 | 같은 보드 안에서만 O |
| 📌 환자 고정, 필터·검색어·기간 | L5 | 이 패널 · 닫힐 때까지 | X |
| 창을 줄여 패널이 작아짐 | L6 | 기억 안 함 | X |
마지막 열이 이 설계가 지키려는 약속이다. "같은 환자를 보는 모든 위젯은 같은 기록을 본다"(L1·L2)와 "내가 이 패널에서만 바꾼 건 이 패널에만 남는다"(L5)가 동시에 성립해야 하고, 그 사이에 "이 보드 안에서만 공유"(L4)와 "이 계정에서만 기억"(L3)이 끼어 있다.
일반화하면 이렇다.
- L0 워크스테이션 설정 —
emr_config.toml. main 프로세스가 읽고 쓴다. 차트 표시 기본값, 이 자리의 담당자 id - L1 서버 상태 캐시 — react-query 하나. 환자 데이터는 오직 여기,
["medical-records", id]처럼 환자 id로 키잉. 고정 패널과 따라가는 패널이 같은 환자를 보면 캐시 엔트리를 공유한다 - L2 공유 문맥 — zustand
{ patient, date, dentist }. 시스템의 "지금 보고 있는 것". 환자를 선택하면 여기로 쓴다. 비영속 - L3 보드 배치 · 패널 설정 — 직렬화된 도킹 트리, 워크스페이스, 패널별 variant·pref. 계정 스코프 localStorage(
deardent-dock-layout::s<staffId>) + 서버 push. 편집 모드·pin은 일부러 여기서 뺐다 - L4 보드 × 기능 슬라이스 — 한 보드의 위젯들이 공유하는 작업 상태 (차트 작업일, 기록 선택, 청구 엔진 소유권). 보드 id × 기능 키마다 스토어가 lazy하게 하나
- L5 패널 인스턴스 — variant·pref는 영속, 축 고정(bindings)은 세션, 필터·검색어 같은 뷰 상태는 remount를 견디는 메모리 Map
- L6 컨테이너 form — ResizeObserver로 셀 크기를 재서
full / compact / mini도출. 저장 안 함
PIN & FOLLOW
L2의 세 값이 핵심이다. 위젯은 레지스트리에서 자기가 소비하는 축을 선언하고(환자 정보는 patient, 접수목록은 date, 청구 목록은 없음), 사용자는 패널 단위로 특정 축을 고정한다. 해석은 축별로 독립적인 순수 함수 하나다.
// docking/scope.ts
export function resolveScope(bindings: WidgetBindings, shared: SharedScope) {
const out: Partial<ContextScope> = {}
for (const key of Object.keys(bindings) as ScopeKey[]) {
const binding = bindings[key]
if (!binding) continue
out[key] = binding.mode === "pinned" ? binding.value : shared[key]
}
return out
}
// docking/use-panel-scope.ts — 레지스트리 기본값 위에 패널 오버라이드를 얹는다
const bindings = { ...meta?.defaultBindings, ...override }
return resolveScope(bindings, shared)
Provider를 쌓다가 버스로 바꿈
한 보드 안 여러 위젯이 공유하는 작업 상태는 처음에 기능마다 React Provider로 만들었다. DocumentWorkspaceProvider, ChartWorkspaceProvider… 하지만 곧 문제가 생겼다. Provider는 트리 위치에 묶이니까 그 기능의 위젯은 그 Provider 아래에만 놓일 수 있다. 위젯을 자유롭게 놓게 하려고 만든 시스템이 위젯을 특정 보드에 가두고 있었다.
지금은 BoardDock이 보드 id 컨텍스트 하나만 제공하고, 각 기능은 zustand creator와 훅 하나를 내보낸다. 스토어는 ${boardId}//${key}마다 모듈 레벨에서 lazy하게 만들어져서 화면을 떠났다 돌아와도 초안이 살아 있고, 보드 밖에서 호출되면 이름 없는 버킷으로 떨어져서 훅이 항상 total이다.
// docking/board-workspace.ts
export function useBoardSlice<T>(key: string, creator: StateCreator<T>): T {
const boardId = useContext(BoardIdContext) ?? NO_BOARD
return useBoardSliceOf(boardId, key, creator)
}
// 기능 쪽은 이 한 줄이면 끝. Provider도, 별도 팔레트 목록도 없다.
export const useChartWorkspace = () => useBoardSlice("chart", createChartWorkspace)
팝아웃이 가르쳐 준 것
팝아웃은 DOM만 다른 OS 창으로 옮기고 React 트리·스토어·캐시는 본창 것을 그대로 쓴다. 덕분에 상태 동기화 버그는 팝아웃에서 안 났다. 대신 다른 버그가 났다. 팝아웃 패널에서 드롭다운을 열면 메뉴가 본창에 그려졌다. Radix 포털이 본창 document.body를 기본값으로 쓰기 때문이다. 결국 UI 라이브러리의 모든 포털 프리미티브가 컨텍스트에서 컨테이너를 받도록 고쳤고, "새 오버레이 컴포넌트는 반드시 usePortalContainer()를 통과한다"가 팀 규칙이 됐다. 토스트도 같은 이유로 창마다 앵커를 따로 둔다.
같은 계열로, 팝아웃이나 드래그가 서브트리를 remount시켜서 위젯의 필터·검색어가 날아갔다. React state로는 못 버티니까 패널 id로 키잉된 메모리 Map(panel-view-state)에 뷰 상태를 두고 패널이 닫힐 때 지운다. 이 Map은 마운트 전에 seed할 수 있어서, AI 어시스턴트가 "이 환자 최근 3개월 진료차트 열어줘"를 실행할 때 필터가 미리 채워진 위젯을 띄우는 경로로도 쓴다.
고민 2 — 조합이 무한한데 어떻게 검증하나
위젯 45종, 축 3개에 고정 여부, 축약 단계, 보기 방식, 창 위치, 데이터 상태. 사용자가 만들 수 있는 배치는 사실상 무한하고 QA가 손으로 재현해 볼 수는 없다. 근데 이 시스템의 약속은 정확히 "어떤 배치에서도 맞는다"였다.
답은 검증을 배치 단위가 아니라 위젯 × 상태 축 단위로 내리는 거였다. 축이 직교하니까 각 축의 값만 돌면 되고, 위젯이 메타에서 허용한 값만 돌면 조합 폭발도 없다. 이걸 위해 Laboratory를 만들었다. dev 빌드의 /lab/observatory 라우트로, 목업이 아니라 앱의 진짜 공유 문맥 스토어와 진짜 react-query 캐시를 공유한 채 모든 위젯을 한 그리드에 실제 컴포넌트로 띄운다.
고립 마운트는 컨텍스트 한 줄이다
"위젯은 props를 받지 않는다"가 여기서 보상을 준다. 도크 안에서 래퍼가 하는 일은 패널 id로 메타·렌더를 찾고 PanelIdContext를 까는 것뿐이라, 실험실은 dockview를 걷어내고 그 컨텍스트만 직접 주입하면 된다. 위젯은 자기가 도크에 있는지 실험실에 있는지 모른다.
// lab/observatory/instrumented-panel.tsx — 고립의 전부
<PanelIdContext value={panelId}> // "chart-treatment-chart#obs-pinned"
<PanelErrorBoundary label={panelId}>{children}</PanelErrorBoundary>
</PanelIdContext>
실험실이 위젯마다 붙이는 계기판은 세 가지다.
- 축별 follow/pin 칩* — 실제
setBinding을 부르는 첫 번째 소비자였다 - 오류 경계 — 패널 하나가 죽어도 옆 패널은 산다
- Query Inspector — 위젯마다 기대하는 react-query 키를 매니페스트로 선언해두고, 헤더에 실제 캐시 키·상태·관찰자 수를 찍는다
3번이 검증의 핵심이다. "같은 환자를 보는 진료차트 두 개 → 같은 캐시 키 → obs 2"가 동기화가 맞다는 문장이고, 위젯이 잘못된 키를 쓰면 매니페스트와 어긋나서 바로 드러난다. 네트워크를 가로챌 필요가 없다.
카탈로그가 뒤처지지 않도록 손으로 고른 시연 그룹 뒤에 레지스트리의 나머지 위젯 전부를 자동으로 마운트한다. 위젯을 하나 추가하면 실험실에 저절로 나타난다.
진료차트를 실험대에 올리면
진료차트를 두 번 띄운다. 하나는 공유 문맥을 따라가고(#obs-follow), 하나는 환자 17에 고정해둔다(#obs-pinned). 그리고 콘솔에서 축을 하나씩 건드린다.
아래는 실제 실험실이 아닌 보안상 예시 목업으로 두었습니다.
축을 하나씩 돌리면 진료차트 한 위젯에 대해 확인해야 할 게 전부 나온다.
| 건드리는 축 | 실험실이 부르는 것 | 두 패널의 기대 반응 | 단 |
|---|---|---|---|
| 환자 선택 | setShared("patient", 23) |
따라가는 패널만 23으로, 고정 패널은 17 유지. 캐시 키 교체 | L2 · L5 |
| 📌 고정 / 해제 | setBinding · clearBinding |
이후 공유값 변경에 무반응. 해제하면 즉시 복귀 | L5 |
| 글자 크기 | updateConfig({ chart_font_scale }) |
두 패널 모두 바뀜. PC 단위라 패널 구분 없음 | L0 |
| 기록 저장 | invalidateQueries(["medical-records", 4]) |
같은 키 관찰자 전부 refetch. 다른 환자 패널 무관 | L1 |
| 기록 행 선택 | record-selection 슬라이스 byPatient[4] |
같은 보드·같은 환자 패널만 강조 | L4 |
| 필터 미리 채움 | 마운트 전 writePanelViewState |
그 패널만 채워진 채 뜸 | L5 |
| 셀 축소 | minSize 224×192 |
forms: ["full"]이라 축약 없이 스크롤. 오류 경계 0 |
L6 |
| API 응답 강제 | vi.mock(apiClient) → 빈 배열 / reject |
EmptyState 또는 오류 표기. 오류 경계로 떨어지면 실패 |
data |
| 날짜 변경 | setShared("date", …) |
아무 변화 없음 | 축 없음 |
진료차트는 date 축을 소비하지 않으니까 "날짜를 바꿔도 아무 일이 없어야 한다"까지 단언 하나다.
에이전트가 직접 배치해 본다
레지스트리가 기계가 읽을 수 있는 선언이라는 게 마지막 조각이다. 위젯이 소비하는 축, 허용하는 축약 단계, 보기 방식, 격자 발자국이 전부 데이터로 있으니까, 에이전트는 사람이 시나리오를 써주길 기다릴 필요 없이 메타에서 배치와 조작 계획을 스스로 만든다. 프리셋이 원래 api.addPanel 호출열이라 에이전트가 만든 배치도 같은 API로 실행된다.
에이전트가 지키는 규칙은 몇 개 안 된다.
- 배치는 메타가 허용하는 범위 안에서만
- 고정 검증은 항상 따라가는 패널 + 고정 패널을 쌍으로 띄워서 공유값을 바꾼 뒤 하나만 변함을 단언. 단일 패널로는 pin을 증명할 수 없다
- 데이터 상태는 스냅샷 날짜와 seed 환자로 라이브를 쓰고, 빈 목록·오류는 API mock으로 강제
- 팝아웃은 Electron 드라이버로 두 번째 창을 잡아서 포털이 그 창에 붙었는지 본다
- 끝나면 보드를 초기화해서 다음 시나리오가 깨끗한 저장 상태에서 시작
테스트 계층은 이렇게 나뉜다.
- 슬라이스 단위 — vitest(node),
createStore(createXxxWorkspace)에 직접 setState - 훅 + 보드 컨텍스트 — vitest(jsdom)
renderHook+BoardIdContext.Provider. 보드 id를 바꿔가며 격리·공유 검증 - 패널 렌더 — Testing Library +
vi.mock("@/renderer/api.ts")로 data 축 - 실험실 스토리 — Storybook + vitest browser mode(playwright chromium). 팝아웃 빼고 모든 축은 여기서 끝남
- Electron e2e — Playwright
electron-native. 팝아웃·복제 창만 여기로
성과
성공 기준은 처음부터 "우리가 만들어주는 화면이 줄었는가"였다. 배치 요청이 사용자 손으로 해결되면 우리는 위젯 만드는 데 집중할 수 있고, 병원은 도입을 미룰 이유가 하나 준다.
| 항목 | 도입 전 | 도입 후 |
|---|---|---|
| 화면 커스터마이징 요청 (월) | — | — |
| 기본 프리셋을 벗어난 배치를 쓰는 병원 | — | —% |
| 배치·상태 동기화 버그 리포트 | — | — |
수치 밖의 변화도 있었다. 위젯을 놓는 방식이 하나로 통일되면서 새 기능을 "화면"이 아니라 "위젯"으로 만들게 됐고, 위젯 하나 추가는 레지스트리 항목 하나 + 렌더 맵 항목 하나로 끝난다. AI 어시스턴트가 위젯을 열고 필터를 미리 채우는 기능도 별도 작업 없이 같은 경로(openWidget + 뷰 상태 seed)로 얻었다.
회고
상태 설계가 굉장히 복잡했었고 dockview 라이브러리를 처음 써보면서 react-portal 등 DOM 조작에 대해 직접적으로 많이 써보면서 배웠던 작업이었다.
dockview 자체를 다루면서 나왔던 문제는 dockview + tanstack-virtual 가상 스크롤 어긋남 - DOM 재부착 문제에 따로 작성해두었다 싶이 단순히 상태설계 뿐 아니라 아키텍처 전반을 폭넓게 공부해볼 수 있었던 프로젝트였던 거 같다.
한계
- 실험실 축이 아직 다 안 열렸다
문맥·고정·데이터·캐시는 조작되지만 축약 단계·보기 방식·창 위치·저장 상태는 실험실 컨트롤이 없다. 실험실은 항상 라이브 agent를 치고 mock 전환은 Storybook과 vitest에만 있다. 에이전트 e2e도 스토리에 play 함수를 붙이는 단계가 남았다 - 위젯 안의 커스터마이징
병원들이 다음으로 요청하는 건 위젯 내부다(열 순서, 표시 항목). 지금은 variant와 panelPrefs로 받는데 둘의 경계를 어디에 둘지 계속 논쟁 중 - 계정 간 배치 공유
배치가 계정 스코프로 서버에 저장되니까 "원장님 배치를 데스크에도"가 기술적으로는 쉽다. 근데 권한과 화면 크기가 다른 자리에 같은 배치를 밀어넣는 게 맞는지는 아직 답이 없다